Skip to content

fix(core): the auth-gate allow-list matches at a mount boundary - #17284

Merged
os-sam merged 3 commits into
mainfrom
claude/issue-16839-auth-gate-anchored-allowlist
Sep 10, 2026
Merged

fix(core): the auth-gate allow-list matches at a mount boundary#17284
os-sam merged 3 commits into
mainfrom
claude/issue-16839-auth-gate-anchored-allowlist

Conversation

@claude

@claude claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #16839

Clause-②: no

(Declared by the claiming seat, not by the implementer — the declaration is the seat's judgement (check-clause2-carriers.mjs: ⛔ do not fill the line in on the claiming seat's behalf), and it matches the Clause-②: no on this card's claim comment. This PR anchors an allow-list: after it the auth-gate exempts strictly FEWER paths than before. Nothing is newly accepted, no export is added, and there is no new schema key, closed-set member or registry entry — so the C5 widening-tell limb has nothing to collide with. Written here pre-emptively: the Check Changeset LEVEL AXIS refuses «clause-② undeclared + every moved packages/**/src/** package graded patch», which is this diff's shape, and it refused PR #17255 for exactly that an hour ago. The remedy is the declaration, never a regrade.)

isAuthGateAllowlisted decides which paths are exempt from the ADR-0069 authentication-policy gate — the gate that holds a session with an expired password or a required MFA enrollment away from protected resources. It matched with two UNANCHORED tests, so a path segment whose VALUE merely spelled an allow-listed token carried the exemption. Object names and record ids are tenant-controlled.

Measured on the built package, before the repair

packages/core built at cf6e0a193b, read through dist/index.js (the published entry), reproducing the card's table exactly:

isAuthGateAllowlisted('/data/auth/123')      -> true    <- object named `auth`
isAuthGateAllowlisted('/meta/auth/objects')  -> true    <- object named `auth`
isAuthGateAllowlisted('/data/x/health')      -> true    <- record whose id is `health`
isAuthGateAllowlisted('/data/xyz/me/apps')   -> true
isAuthGateAllowlisted('/auth/me')            -> true    (control: genuinely exempt)
isAuthGateAllowlisted('/data/contacts/1')    -> false   (control: correctly gated)

Reachability, read rather than assumed. Both seams hand this predicate a data-plane path directly: HttpDispatcher.enforceAuthGate(context, cleanPath) is called at http-dispatcher.ts:2546before the scoped-URL strip at :2593, so the environment-scoped spelling reaches it too — and RestServer.enforceAuth passes req.path (rest-server.ts:1882). At the dispatcher, cleanPath is the hono adapter's subPath (c.req.path.substring(prefix.length)), i.e. already prefix-stripped. This repo's own tree already records the REST half: the #15021 fault-window measurement notes that /api/v1/data/:object with object = health materializes /api/v1/data/health, and pinned that it answered true.

The repair

The path is read as segments, and every test is anchored to a mount boundary:

  • Mount bases/api/v1, /api, or the empty base the dispatcher sees. These are the same three bases the pre-anchoring ALLOW_PREFIXES already enumerated; the file's existing position was that the bases are enumerable, and this keeps it.
  • At most one environment scope immediately after the base (/environments/ENV_ID, or ADR-0006's superseded /projects/ENV_ID), because the dispatcher evaluates the gate before its scoped strip. Recognising the scope only immediately after a base is what keeps /data/environments/x/health from reading as a scoped /health.
  • /auth/… at that position stays exempt — the anchored replacement for path.includes('/auth/'). Bare BASE/auth stays exempt only unscoped, which is exactly what the old ALLOW_PREFIXES equality branch admitted.
  • The five bootstrap reads are exact routes at that mount instead of suffixes.

A rule that accepts an arbitrary base cannot be written. /rest/v2/health and /data/xyz/health are the same shape, so base-agnosticism is the defect. Enumerating is the only correct answer, and it costs nothing at either live seam: the dispatcher's path arrives base-stripped (matched by the empty base), and REST registers its control-plane routes without enforceAuth at all (rest-server.ts:1891). The changeset carries the note for anyone gating a custom mount through this predicate.

The semver grade, and the measurement that picked it

@objectstack/core: patch.

  1. Is it published? Measured, not declared. packages/core/package.json maps exports["."]./dist/index.js / ./dist/index.d.ts with files: ["dist", …]; dist/index.d.ts declares isAuthGateAllowlisted and names it in the entry's export { … } list, and typeof (await import('./dist/index.js')).isAuthGateAllowlisted === 'function'. ⇒ consumers see this behaviour change on a published surface, so not skip-changeset.
  2. Is the published type narrowed? No. (rawPath: string | undefined | null) => boolean before and after, byte-identical in dist/index.d.ts. ⇒ not the minor + BREAKING + adr-0087: rung.
  3. New public-entry export? No — the two constants that changed (ALLOW_PREFIXES/ALLOW_SUFFIXESMOUNT_BASES/ALLOW_ROUTES) were and remain module-private.
  4. ⭐ Landed precedent, which the dispatch said beats both its order and my reasoning. .changeset/auth-domain-claim-segment-boundary.md (PR fix(runtime): stop the /auth domain claiming every path that merely starts with auth #16265, released, now in CHANGELOG) graded exactly this shape — one unanchored path predicate anchored to a segment boundary in a published package, behaviour narrows, no type moves — as patch. That card is the /auth member of the very sweep this one belongs to. AGENTS.md § Post-Task Checklist step 3 says the same thing independently: "A bug fix in a released package takes a patch changeset."
  5. ⚠️ The sibling PR fix(runtime): domain claims and the membership skip list stop at a segment boundary #16842 graded minor, and it is not the precedent to follow: it also moved a published default (DomainRoute.match from 'prefix' to 'segment'), which is a config surface authors write. Nothing here moves a default or a declared surface.

Clause ② — measured, not asserted

The dispatch declared clause ② no ("nothing is newly accepted; the allowlist shrinks") and asked me to stop and report if any path became newly exempt. None does. Over a generated corpus of 111,152 paths (every path up to four segments over the vocabulary the two spellings can disagree on), against the pre-anchoring predicate transcribed verbatim:

widened (new && !old) = 0
narrowed (!new && old) = 25,979

That check is kept as a test, with the superseded predicate beside it, so a later widening cannot arrive quietly.

Tests — failing before, passing after, both directions pinned

New block [#16839] a tenant-controlled segment cannot buy the exemption in packages/core/src/security/auth-gate.test.ts. It pins the DECISION per path, not the spelling of the predicate.

Ablation (the fail-before leg). Fix committed first, then packages/core/src/security/auth-gate.ts restored to cf6e0a193b and proven on disk by blob hash (git hash-object = 8ffa74f2… = the BASE blob; anchored marker MOUNT_BASES count 0, unanchored path.includes('/auth/') count 1), run, then restored via git checkout HEAD -- PATH with git diff HEAD empty as the restore proof. Both legs under a trap … EXIT INT TERM with absolute paths.

ABLATED (pre-anchoring implementation, new tests in place):
  Test Files  1 failed (1)
       Tests  3 failed | 14 passed (17)
  FAIL  … > gates the four paths that were falsely exempt
        AssertionError: /data/auth/123: expected true to be false
  FAIL  … > gates the same shapes under the REST mount, where the seam sees the base
        AssertionError: /api/v1/data/auth/123: expected true to be false
  FAIL  … > is a strict SUBSET of the pre-anchoring allow-list
        AssertionError: expected 0 to be greater than 0      <- anti-vacuity leg

REPAIRED:
  Test Files  1 passed (1)
       Tests  17 passed (17)

The four control legs (/auth/me exempt, /data/contacts/1 gated, and the twenty-one genuinely-exempt route shapes) pass in both runs — which is the point: they are the direction the repair must not move.

Suites.

command verdict
pnpm --filter @objectstack/core build check-dts-emitted: 2/2 declaration file(s) present
pnpm --filter @objectstack/core test Test Files 51 passed (51) · Tests 1261 passed (1261)
pnpm --filter @objectstack/core typecheck exit 0 (incl. check:test-typecheck over tsconfig.test.json)
pnpm --filter @objectstack/rest test Test Files 186 passed (186) · Tests 3111 passed (3111)
pnpm --filter @objectstack/rest typecheck exit 0
pnpm lint (repo-wide union, eslint . --no-inline-config) exit 0 at d00421281b

Gates. 41 of the 43 derived families that were still outstanding ran clean, plus 18 run earlier — check:nul-bytes, check:platform-checklist, check:changeset-no-major, check:adr-0087-registration, check:empty-changeset, check:objectui-changeset, check:changeset-gate-self-tests, check:closing-keyword-parity, check:pm-changeset-deadline-census, check:cross-package-test-inputs, check:test-source-alias, check:undeclared-dep-imports among them. Exit codes captured by redirect-then-$?, never across a pipe.

⚠️ NOT MEASURED (exit 3 = PREREQUISITE NOT MET, which is never a pass): pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt. Both refuse without a whole-repo dist/ (turbo run build across every package), which is a repo-wide build this environment reserves for CI. Neither reads anything this diff touches outside packages/core, which is built here — but that is a reading, not a run, and CI owns the verdict.

Two compelled edits outside packages/core

Both are consequences of this diff, not scope creep:

  1. packages/rest/src/auth-gate-allowlist-fault-window.measurement.test.ts §3 pinned isAuthGateAllowlisted('/api/v1/data/health') === true — the exact over-broad answer this card removes. That file's own header instructs: "If a repair lands, INVERT THESE PINS IN PLACE … re-aim the assertion here and quote the superseded text beside it … ⛔ Do not delete the leg." Done exactly that, with the superseded assertion and its reason quoted verbatim, the leg's reading unchanged, and a positive control added so the flip cannot read as "the predicate started refusing everything".
  2. docs/qa/platform-checklist/areas/access-security.json cited auth-gate.ts#ALLOW_SUFFIXES. Deleting that symbol dropped the file's resolvable-anchor count from 44 to 43 and pnpm check:platform-checklist went red — the gate's own remedy is "re-author the citation with a symbol this gate can resolve" (lowering the baseline floor is the maintainer-only path and was not taken). Re-pointed at the successor ALLOW_ROUTES with the change recorded in the citation text. Gate green: symbol anchors: 631/631 resolved … 17 file floors held.

Acceptance notes

Out of scope, observed, not filed:

  • HttpDispatcher.enforceAuthGate fails open on any exception (catch { return null; // fail-open — never break dispatch on a gate hiccup }, http-dispatcher.ts). Declared behaviour with a stated rationale, not a defect, and changing a fail-open/fail-closed posture is runtime authorization behaviour reserved to the maintainer. Successor: whoever next revisits ADR-0069's posture — the same seat that owns #15021's fail-closed window, which is about the sibling half of this door.
  • At the REST seam the allow-list is close to inert: enforceAuth is only registered on data/meta routes, and the #15021 census measured that the sole allow-listed mounted REST route pattern is /discovery. Successor: #15021, whose §3 already records the census.
  • BASE/environments/ENV_ID/auth with nothing under it is not exempt while the unscoped BASE/auth is — an asymmetry inherited from the old ALLOW_PREFIXES equality branch and preserved deliberately, because removing it would make a path newly exempt and clause ② is the seat's to change. Successor: none; recorded here so a later reader does not read it as an oversight.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU


Generated by Claude Code

`isAuthGateAllowlisted` decides which paths are exempt from the ADR-0069
authentication-policy gate — the gate that holds a session with an expired
password or a required MFA enrollment away from protected resources. It
matched with two UNANCHORED tests: `path.includes('/auth/')` at any position,
and an `endsWith` test over five suffixes at any depth. A path segment whose
VALUE merely spelled one of those tokens therefore carried the exemption, and
object names and record ids are tenant-controlled.

Both seams hand the predicate a data-plane path directly — the dispatcher
passes `cleanPath`, REST passes `req.path` — so these were reachable requests.
Measured on the built package before the repair: `/data/auth/123`,
`/meta/auth/objects`, `/data/x/health` and `/data/xyz/me/apps` were exempt,
with `/auth/me` exempt and `/data/contacts/1` gated as controls.

The path is read as segments now, and every test is anchored to a mount base
(`/api/v1`, `/api`, or the empty base the dispatcher sees) plus at most one
environment scope immediately after it — the scoped spelling reaches the
predicate because the dispatcher evaluates the gate before its scoped-URL
strip. `/auth/…` at that position stays exempt; the five bootstrap reads are
exact routes there instead of suffixes.

Only exemptions are removed: over a generated corpus of 111,152 paths, newly
exempt = 0 and 25,979 stopped being exempt. That subset check is kept as a
test with the pre-anchoring predicate transcribed beside it, so a later
widening cannot arrive quietly.

The platform-checklist citation that named the deleted `ALLOW_SUFFIXES` is
re-authored onto the successor symbol rather than dropped, and the REST
fault-window measurement pin that recorded the over-broad answer is re-aimed
in place with its superseded text quoted, per that file's own instruction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…nstants

The comment named `ALLOW_PREFIXES` and `ALLOW_SUFFIXES` as the constants its
path shapes were read off. Anchoring the allow-list replaced both, so the
reference dangled; the shapes themselves are unchanged and §0 still drives
that they are allow-listed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/core, touching 11 documentable anchor(s).

16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d64bcb63772b850b3ca8ae39779d492e46d3a0a6.

5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 25 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d64bcb63772b850b3ca8ae39779d492e46d3a0a6packageMentionDocs.

Which tree this was computed on

This run read content/docs from 99d010ad934c5b6e6323f7abcc9abc13c2d5eb96 — the merge of head d00421281b3aeb2a48b728dbb8dc42bf5743e4cd into base d64bcb63772b850b3ca8ae39779d492e46d3a0a6, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 99d010ad934c5b6e6323f7abcc9abc13c2d5eb96 && git checkout 99d010ad934c5b6e6323f7abcc9abc13c2d5eb96
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d64bcb63772b850b3ca8ae39779d492e46d3a0a6 d00421281b3aeb2a48b728dbb8dc42bf5743e4cd && git checkout -B drift-repro d64bcb63772b850b3ca8ae39779d492e46d3a0a6 && git merge --no-ff d00421281b3aeb2a48b728dbb8dc42bf5743e4cd

node scripts/docs-audit/affected-docs.mjs --json d64bcb63772b850b3ca8ae39779d492e46d3a0a6

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d64bcb63772b850b3ca8ae39779d492e46d3a0a6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

isAuthGateAllowlisted exempts by UNANCHORED substring/suffix — an object named auth or a record with id health bypasses the ADR-0069 auth-policy gate

2 participants